SPB Git

spb/worthdoing Public

Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL

TypeScript 91.5% SQL 5.8% CSS 2.2%
11.1 KB · 294 lines tsx
Raw Blame History
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/app/investigate/[id]/InvestigationLive.tsx6 * Description: Live investigation client view — SSE timeline, animating hypotheses, opportunities, streamed reports; mobile tabs.7 */8"use client";910import { useCallback, useEffect, useRef, useState } from "react";11import Link from "next/link";12import { cn } from "@/lib/utils";13import {14  useInvestigationEvents,15  useSnapshot,16  type UiAgentEvent,17} from "@/lib/ui/api";18import { Timeline } from "@/components/wd/Timeline";19import { HypothesisPanel } from "@/components/wd/HypothesisPanel";20import { StatsBar } from "@/components/wd/StatsBar";21import { PhaseBadge, InvestigationStatusBadge } from "@/components/wd/badges";22import { WorthScore } from "@/components/wd/OpportunityCard";23import { Markdown } from "@/components/wd/Markdown";2425const STRUCTURAL_EVENTS = new Set([26  "investigation.started",27  "phase.changed",28  "budget.updated",29  "hypothesis.created",30  "hypothesis.updated",31  "hypothesis.rejected",32  "evidence.saved",33  "opportunity.created",34  "report.completed",35  "investigation.completed",36  "investigation.failed",37]);3839type MobileTab = "timeline" | "hypotheses" | "opportunities";4041export function InvestigationLive({ id }: { id: string }) {42  const { snapshot, refresh, error } = useSnapshot(id);43  const [events, setEvents] = useState<UiAgentEvent[]>([]);44  const [reportDraft, setReportDraft] = useState<{ opportunityId: string; text: string } | null>(null);45  const [tab, setTab] = useState<MobileTab>("timeline");46  const feedRef = useRef<HTMLDivElement>(null);47  const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);4849  const onEvent = useCallback(50    (e: UiAgentEvent) => {51      if (e.type === "report.delta") {52        const oid = String(e.payload.opportunityId);53        setReportDraft((prev) =>54          prev && prev.opportunityId === oid55            ? { opportunityId: oid, text: prev.text + String(e.payload.delta) }56            : { opportunityId: oid, text: String(e.payload.delta) },57        );58        return;59      }60      if (e.type === "report.started") setReportDraft({ opportunityId: String(e.payload.opportunityId), text: "" });61      if (e.type === "report.completed") setReportDraft(null);6263      setEvents((prev) => {64        if (prev.some((p) => p.seq === e.seq)) return prev;65        const next = [...prev, e].sort((a, b) => a.seq - b.seq);66        return next.length > 400 ? next.slice(next.length - 400) : next;67      });6869      if (STRUCTURAL_EVENTS.has(e.type)) {70        // Debounce snapshot refreshes so bursts don't stampede the API.71        if (refreshTimer.current) clearTimeout(refreshTimer.current);72        refreshTimer.current = setTimeout(refresh, 400);73      }74    },75    [refresh],76  );7778  const { connected } = useInvestigationEvents(id, onEvent);7980  // Keep the feed pinned to the latest event.81  useEffect(() => {82    const el = feedRef.current;83    if (el) el.scrollTop = el.scrollHeight;84  }, [events.length, reportDraft?.text.length]);8586  if (error) {87    return (88      <div className="mx-auto max-w-2xl px-4 py-24 text-center">89        <p className="font-heading text-2xl text-ink">Investigation not found</p>90        <p className="mt-2 text-sm text-ink-soft">{error}</p>91        <Link href="/" className="mt-6 inline-block rounded-md bg-ink px-4 py-2 text-sm text-paper">92          Start a new one93        </Link>94      </div>95    );96  }97  if (!snapshot) {98    return (99      <div className="flex items-center justify-center py-32">100        <span className="wd-live-dot h-2.5 w-2.5 rounded-full bg-verdict" />101      </div>102    );103  }104105  const inv = snapshot.investigation;106  const isLive = inv.status === "running" || inv.status === "pending";107108  const rightColumn = (109    <>110      <section className="rounded-xl border border-line bg-card p-4">111        <h2 className="mb-3 flex items-center justify-between font-mono text-[11px] uppercase tracking-widest text-ink-soft">112          Hypotheses113          <span className="text-ink">{snapshot.hypotheses.length}</span>114        </h2>115        <HypothesisPanel hypotheses={snapshot.hypotheses} />116      </section>117118      <section className="rounded-xl border border-line bg-card p-4">119        <h2 className="mb-3 flex items-center justify-between font-mono text-[11px] uppercase tracking-widest text-ink-soft">120          Opportunities121          <span className="text-ink">{snapshot.opportunities.length}</span>122        </h2>123        {snapshot.opportunities.length === 0 ? (124          <p className="py-4 text-center font-mono text-xs text-ink-soft">125            none yet — opportunities appear when hypotheses survive the skeptic126          </p>127        ) : (128          <ul className="space-y-3">129            {snapshot.opportunities.map((o) => (130              <li key={o.id} className="wd-enter">131                <Link132                  href={o.hasReport ? `/opportunity/${o.id}` : "#"}133                  className={cn(134                    "flex items-start justify-between gap-3 rounded-lg border border-line p-3 transition-colors",135                    o.hasReport ? "hover:border-verdict/40 hover:bg-accent/50" : "cursor-default opacity-80",136                  )}137                >138                  <div className="min-w-0">139                    <p className="text-[13px] font-medium leading-snug text-ink">{o.title}</p>140                    <p className="mt-1 line-clamp-2 text-xs text-ink-soft">{o.summary}</p>141                    {!o.hasReport && (142                      <p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-signal">143                        report in progress…144                      </p>145                    )}146                  </div>147                  <WorthScore score={o.worthScore} confidence={o.evidenceConfidence} />148                </Link>149              </li>150            ))}151          </ul>152        )}153      </section>154155      {snapshot.sources.length > 0 && (156        <section className="rounded-xl border border-line bg-card p-4">157          <h2 className="mb-3 flex items-center justify-between font-mono text-[11px] uppercase tracking-widest text-ink-soft">158            Sources with evidence159            <span className="text-ink">{snapshot.sources.length}</span>160          </h2>161          <ul className="space-y-1.5">162            {snapshot.sources.map((s) => (163              <li key={s.id} className="truncate">164                <a165                  href={s.canonicalUrl}166                  target="_blank"167                  rel="noopener noreferrer"168                  className="font-mono text-[11px] text-tele hover:underline"169                >170                  {s.title ?? s.canonicalUrl}171                </a>172              </li>173            ))}174          </ul>175        </section>176      )}177    </>178  );179180  const timelinePane = (181    <section className="flex min-h-0 flex-col rounded-xl border border-line bg-card">182      <div className="flex items-center justify-between border-b border-line px-4 py-2.5">183        <h2 className="font-mono text-[11px] uppercase tracking-widest text-ink-soft">Investigation log</h2>184        <span185          className={cn(186            "flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider",187            connected ? "text-verdict" : "text-signal",188          )}189        >190          <span className={cn("h-1.5 w-1.5 rounded-full bg-current", connected && isLive && "wd-live-dot")} />191          {connected ? (isLive ? "live" : "replay") : "reconnecting"}192        </span>193      </div>194      <div ref={feedRef} className="min-h-[300px] flex-1 overflow-y-auto p-4 lg:max-h-[calc(100vh-260px)]">195        <Timeline events={events} />196        {reportDraft && (197          <div className="wd-enter mt-4 rounded-lg border border-verdict/30 bg-accent/40 p-4">198            <p className="mb-2 font-mono text-[10px] uppercase tracking-widest text-verdict">199              writing report — streaming200            </p>201            <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-ink/90">202              {reportDraft.text}203              <span className="wd-live-dot ml-0.5 inline-block h-3.5 w-[2px] translate-y-0.5 bg-verdict" />204            </p>205          </div>206        )}207      </div>208    </section>209  );210211  return (212    <div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6">213      <div className="mb-4 flex flex-wrap items-center gap-2">214        <InvestigationStatusBadge status={inv.status} live />215        <PhaseBadge phase={inv.phase} live={isLive} />216        <span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-ink-soft">{inv.model}</span>217      </div>218      <h1 className="mb-4 max-w-4xl font-heading text-2xl font-semibold leading-tight text-ink sm:text-3xl">219        {inv.objective}220      </h1>221222      {inv.status === "completed" && inv.conclusion && (223        <div className="wd-enter mb-4 rounded-xl border border-verdict/30 bg-accent/50 p-4">224          <p className="font-mono text-[10px] uppercase tracking-widest text-verdict">225            conclusion — {inv.outcome?.replaceAll("_", " ")}226          </p>227          <div className="mt-2">228            <Markdown>{inv.conclusion}</Markdown>229          </div>230        </div>231      )}232      {inv.status === "failed" && (233        <div className="mb-4 rounded-xl border border-rust/30 bg-rust/5 p-4">234          <p className="font-mono text-[10px] uppercase tracking-widest text-rust">investigation failed</p>235          <p className="mt-2 text-sm text-ink">{inv.error ?? "Unknown error."}</p>236        </div>237      )}238239      <div className="sticky top-14 z-30 -mx-1 bg-paper/95 px-1 py-2 backdrop-blur-sm">240        <StatsBar241          budget={inv.budget}242          used={inv.budgetUsed}243          evidenceCount={snapshot.evidenceCount}244          hypothesisCount={snapshot.hypotheses.length}245        />246      </div>247248      {/* Mobile tabs */}249      <div className="mt-3 flex gap-1 rounded-lg border border-line bg-card p-1 lg:hidden">250        {(251          [252            ["timeline", "Timeline"],253            ["hypotheses", "Hypotheses"],254            ["opportunities", "Results"],255          ] as [MobileTab, string][]256        ).map(([key, label]) => (257          <button258            key={key}259            onClick={() => setTab(key)}260            className={cn(261              "min-h-[44px] flex-1 rounded-md text-sm font-medium transition-colors",262              tab === key ? "bg-ink text-paper" : "text-ink-soft hover:text-ink",263            )}264          >265            {label}266          </button>267        ))}268      </div>269270      {/* Mobile stacked view */}271      <div className="mt-3 space-y-4 lg:hidden">272        {tab === "timeline" && timelinePane}273        {tab !== "timeline" && (274          <div className="space-y-4">275            {tab === "hypotheses" ? (276              <section className="rounded-xl border border-line bg-card p-4">277                <HypothesisPanel hypotheses={snapshot.hypotheses} />278              </section>279            ) : (280              rightColumn281            )}282          </div>283        )}284      </div>285286      {/* Desktop split view */}287      <div className="mt-4 hidden gap-4 lg:grid lg:grid-cols-[1.4fr_1fr]">288        {timelinePane}289        <div className="space-y-4">{rightColumn}</div>290      </div>291    </div>292  );293}294